Una guía completa de genéricos en TypeScript que cubre sintaxis, beneficios y uso avanzado para manejar tipos de datos complejos en el desarrollo de software global.
Genéricos en TypeScript: Dominando Tipos de Datos Complejos para Aplicaciones Robustas
TypeScript, un superconjunto de JavaScript, capacita a los desarrolladores para escribir código más robusto y mantenible a través del tipado estático. Entre sus características más potentes se encuentran los genéricos, que permiten escribir código que puede funcionar con una variedad de tipos de datos manteniendo al mismo tiempo la seguridad de tipos. Esta guía ofrece una exploración exhaustiva de los genéricos de TypeScript, centrándose en su aplicación a tipos de datos complejos en el contexto del desarrollo de software global.
¿Qué son los genéricos?
Los genéricos proporcionan una forma de escribir código reutilizable que puede funcionar con diferentes tipos. En lugar de escribir funciones o clases separadas para cada tipo que desees admitir, puedes escribir una única función o clase que utilice parámetros de tipo. Estos parámetros de tipo son marcadores de posición para los tipos reales que se utilizarán cuando la función o clase sea llamada o instanciada. Esto es especialmente útil al tratar con estructuras de datos complejas donde el tipo de datos dentro de esas estructuras puede variar.
Beneficios de usar genéricos
- Reutilización de código: Escribe código una vez y úsalo con diferentes tipos. Esto reduce la duplicación de código y hace que tu base de código sea más fácil de mantener.
- Seguridad de tipos: Los genéricos permiten que el compilador de TypeScript aplique la seguridad de tipos en tiempo de compilación. Esto ayuda a prevenir errores en tiempo de ejecución relacionados con tipos no coincidentes.
- Legibilidad mejorada: Los genéricos hacen que tu código sea más legible al indicar claramente los tipos con los que tus funciones y clases están diseñadas para trabajar.
- Rendimiento mejorado: En algunos casos, los genéricos pueden llevar a mejoras de rendimiento porque el compilador puede optimizar el código generado basándose en los tipos específicos que se están utilizando.
Sintaxis básica de los genéricos
La sintaxis básica de los genéricos implica el uso de corchetes angulares (< >) para declarar parámetros de tipo. Estos parámetros de tipo suelen nombrarse T
, K
, V
, etc., pero puedes usar cualquier identificador válido. Aquí tienes un ejemplo sencillo de una función genérica:
function identity<T>(arg: T): T {
return arg;
}
let myString: string = identity<string>("hello");
let myNumber: number = identity<number>(123);
let myBoolean: boolean = identity<boolean>(true);
console.log(myString); // Salida: hello
console.log(myNumber); // Salida: 123
console.log(myBoolean); // Salida: true
En este ejemplo, <T>
declara un parámetro de tipo llamado T
. La función identity
toma un argumento de tipo T
y devuelve un valor de tipo T
. Al llamar a la función, puedes especificar explícitamente el parámetro de tipo (p. ej., identity<string>
) o dejar que TypeScript lo infiera basándose en el tipo del argumento.
Trabajando con tipos de datos complejos
Los genéricos se vuelven particularmente valiosos cuando se trata de tipos de datos complejos como arrays, objetos e interfaces. Exploremos algunos escenarios comunes:
Arrays genéricos
Puedes usar genéricos para crear funciones o clases que trabajen con arrays de diferentes tipos:
function arrayToString<T>(arr: T[]): string {
return arr.join(", ");
}
let numberArray: number[] = [1, 2, 3, 4, 5];
let stringArray: string[] = ["apple", "banana", "cherry"];
console.log(arrayToString(numberArray)); // Salida: 1, 2, 3, 4, 5
console.log(arrayToString(stringArray)); // Salida: apple, banana, cherry
Aquí, la función arrayToString
toma un array de tipo T[]
y devuelve una representación en cadena de texto del array. Esta función funciona con arrays de cualquier tipo, lo que la hace altamente reutilizable.
Objetos genéricos
Los genéricos también se pueden usar para definir funciones o clases que trabajen con objetos de diferentes formas:
interface Person {
name: string;
age: number;
country: string; // Se añade el país para el contexto global
}
interface Product {
id: number;
name: string;
price: number;
currency: string; // Se añade la moneda para el contexto global
}
function displayInfo<T extends { name: string }>(item: T): void {
console.log(`Name: ${item.name}`);
}
let person: Person = { name: "Alice", age: 30, country: "USA" };
let product: Product = { id: 1, name: "Laptop", price: 1200, currency: "USD" };
displayInfo(person); // Salida: Name: Alice
displayInfo(product); // Salida: Name: Laptop
En este ejemplo, la función displayInfo
toma un objeto de tipo T
que debe tener una propiedad name
de tipo string. La cláusula extends { name: string }
es una restricción, que especifica los requisitos mínimos para el parámetro de tipo T
. Esto asegura que la función pueda acceder de forma segura a la propiedad name
.
Uso avanzado de genéricos
Los genéricos de TypeScript ofrecen características más avanzadas que te permiten crear código aún más flexible y potente. Exploremos algunas de estas características:
Múltiples parámetros de tipo
Puedes definir funciones o clases con múltiples parámetros de tipo:
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
interface Name {
firstName: string;
}
interface Age {
age: number;
}
const person: Name = { firstName: "Bob" };
const details: Age = { age: 42 };
const merged = merge(person, details);
console.log(merged.firstName); // Salida: Bob
console.log(merged.age); // Salida: 42
La función merge
toma dos objetos de tipos T
y U
y devuelve un nuevo objeto que contiene las propiedades de ambos objetos. Esta es una forma poderosa de combinar datos de diferentes fuentes.
Restricciones de genéricos
Como se mostró anteriormente, las restricciones te permiten limitar los tipos que se pueden usar con un parámetro de tipo genérico. Esto asegura que el código genérico pueda operar de forma segura sobre los tipos especificados.
interface Lengthwise {
length: number;
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length);
return arg;
}
loggingIdentity([1, 2, 3]); // Salida: 3
loggingIdentity("hello"); // Salida: 5
// loggingIdentity(123); // Error: El argumento de tipo 'number' no es asignable al parámetro de tipo 'Lengthwise'.
La función loggingIdentity
toma un argumento de tipo T
que debe tener una propiedad length
de tipo number. Esto asegura que la función pueda acceder de forma segura a la propiedad length
.
Clases genéricas
Los genéricos también se pueden usar con clases:
class DataStorage<T> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
this.data = this.data.filter(d => d !== item);
}
getItems(): T[] {
return [...this.data];
}
}
const textStorage = new DataStorage<string>();
textStorage.addItem("apple");
textStorage.addItem("banana");
textStorage.removeItem("apple");
console.log(textStorage.getItems()); // Salida: [ 'banana' ]
const numberStorage = new DataStorage<number>();
numberStorage.addItem(1);
numberStorage.addItem(2);
numberStorage.removeItem(1);
console.log(numberStorage.getItems()); // Salida: [ 2 ]
La clase DataStorage
puede almacenar datos de cualquier tipo T
. Esto te permite crear estructuras de datos reutilizables que son seguras en cuanto a tipos.
Interfaces genéricas
Las interfaces genéricas son útiles para definir contratos que pueden funcionar con diferentes tipos. Por ejemplo:
interface Result<T, E> {
success: boolean;
data?: T;
error?: E;
}
interface User {
id: number;
username: string;
email: string;
}
interface ErrorMessage {
code: number;
message: string;
}
function fetchUser(id: number): Result<User, ErrorMessage> {
if (id === 1) {
return { success: true, data: { id: 1, username: "john.doe", email: "john.doe@example.com" } };
} else {
return { success: false, error: { code: 404, message: "Usuario no encontrado" } };
}
}
const userResult = fetchUser(1);
if (userResult.success) {
console.log(userResult.data.username);
} else {
console.log(userResult.error.message);
}
La interfaz Result
define una estructura genérica para representar el resultado de una operación. Puede contener datos de tipo T
o un error de tipo E
. Este es un patrón común para manejar operaciones asíncronas u operaciones que pueden fallar.
Tipos de utilidad y genéricos
TypeScript proporciona varios tipos de utilidad incorporados que funcionan bien con genéricos. Estos tipos de utilidad pueden ayudarte a transformar y manipular tipos de formas potentes.
Partial<T>
Partial<T>
hace que todas las propiedades del tipo T
sean opcionales:
interface Person {
name: string;
age: number;
}
type PartialPerson = Partial<Person>;
const partialPerson: PartialPerson = { name: "Alice" }; // Válido
Readonly<T>
Readonly<T>
hace que todas las propiedades del tipo T
sean de solo lectura:
interface Person {
name: string;
age: number;
}
type ReadonlyPerson = Readonly<Person>;
const readonlyPerson: ReadonlyPerson = { name: "Bob", age: 42 };
// readonlyPerson.age = 43; // Error: No se puede asignar a 'age' porque es una propiedad de solo lectura.
Pick<T, K>
Pick<T, K>
selecciona un conjunto de propiedades K
del tipo T
:
interface Person {
name: string;
age: number;
email: string;
}
type NameAndAge = Pick<Person, "name" | "age">;
const nameAndAge: NameAndAge = { name: "Charlie", age: 28 };
Omit<T, K>
Omit<T, K>
elimina un conjunto de propiedades K
del tipo T
:
interface Person {
name: string;
age: number;
email: string;
}
type PersonWithoutEmail = Omit<Person, "email">;
const personWithoutEmail: PersonWithoutEmail = { name: "David", age: 35 };
Record<K, T>
Record<K, T>
crea un tipo con claves K
y valores de tipo T
:
type CountryCodes = "US" | "CA" | "UK" | "DE" | "FR" | "JP" | "CN" | "IN" | "BR" | "AU"; // Lista ampliada para el contexto global
type Currency = "USD" | "CAD" | "GBP" | "EUR" | "JPY" | "CNY" | "INR" | "BRL" | "AUD"; // Lista ampliada para el contexto global
type CurrencyMap = Record<CountryCodes, Currency>;
const currencyMap: CurrencyMap = {
"US": "USD",
"CA": "CAD",
"UK": "GBP",
"DE": "EUR",
"FR": "EUR",
"JP": "JPY",
"CN": "CNY",
"IN": "INR",
"BR": "BRL",
"AU": "AUD",
};
Tipos mapeados
Los tipos mapeados te permiten transformar tipos existentes iterando sobre sus propiedades. Esta es una forma potente de crear nuevos tipos basados en los existentes. Por ejemplo, puedes crear un tipo que haga que todas las propiedades de otro tipo sean de solo lectura:
interface Person {
name: string;
age: number;
}
type ReadonlyPerson = {
readonly [K in keyof Person]: Person[K];
};
const readonlyPerson: ReadonlyPerson = { name: "Eve", age: 25 };
// readonlyPerson.age = 26; // Error: No se puede asignar a 'age' porque es una propiedad de solo lectura.
En este ejemplo, [K in keyof Person]
itera sobre todas las claves de la interfaz Person
, y Person[K]
accede al tipo de cada propiedad. La palabra clave readonly
hace que cada propiedad sea de solo lectura.
Tipos condicionales
Los tipos condicionales te permiten definir tipos basados en condiciones. Esta es una forma potente de crear tipos que se adaptan a diferentes escenarios.
type NonNullable<T> = T extends null | undefined ? never : T;
type MaybeString = string | null | undefined;
type StringType = NonNullable<MaybeString>; // string
function getValue<T>(value: T): NonNullable<T> {
if (value == null) { // Maneja tanto null como undefined
throw new Error("El valor no puede ser nulo o indefinido");
}
return value as NonNullable<T>;
}
try {
const validValue = getValue("hello");
console.log(validValue.toUpperCase()); // Salida: HELLO
const invalidValue = getValue(null); // Esto lanzará un error
console.log(invalidValue); // Esta línea no se alcanzará
} catch (error: any) {
console.error(error.message); // Salida: El valor no puede ser nulo o indefinido
}
En este ejemplo, el tipo NonNullable<T>
comprueba si T
es null
o undefined
. Si lo es, devuelve never
, lo que significa que el tipo no está permitido. De lo contrario, devuelve T
. Esto te permite crear tipos que se garantiza que no son nulos.
Mejores prácticas para usar genéricos
Aquí hay algunas mejores prácticas a tener en cuenta al usar genéricos:
- Usa nombres de parámetros de tipo descriptivos: Elige nombres que indiquen claramente el propósito del parámetro de tipo.
- Usa restricciones para limitar los tipos que se pueden usar con un parámetro de tipo genérico: Esto asegura que tu código genérico pueda operar de forma segura sobre los tipos especificados.
- Mantén tu código genérico simple y enfocado: Evita complicar en exceso tu código genérico con demasiados parámetros de tipo o restricciones complejas.
- Documenta tu código genérico a fondo: Explica el propósito de los parámetros de tipo y cualquier restricción que se utilice.
- Considera las ventajas y desventajas entre la reutilización de código y la seguridad de tipos: Si bien los genéricos pueden mejorar la reutilización del código, también pueden hacer que tu código sea más complejo. Sopesa los beneficios y los inconvenientes antes de usar genéricos.
- Considera la localización y la globalización (l10n y g11n): Cuando trabajes con datos que necesitan ser mostrados a usuarios en diferentes regiones, asegúrate de que tus genéricos admitan el formato y las convenciones culturales apropiadas. Por ejemplo, el formato de números y fechas puede variar significativamente entre distintas configuraciones regionales.
Ejemplos en un contexto global
Consideremos algunos ejemplos de cómo se pueden usar los genéricos en un contexto global:
Conversión de moneda
interface ConversionRate {
rate: number;
fromCurrency: string;
toCurrency: string;
}
function convertCurrency<T extends ConversionRate>(amount: number, rate: T): number {
return amount * rate.rate;
}
const usdToEurRate: ConversionRate = { rate: 0.85, fromCurrency: "USD", toCurrency: "EUR" };
const amountInUSD = 100;
const amountInEUR = convertCurrency(amountInUSD, usdToEurRate);
console.log(`${amountInUSD} USD equivalen a ${amountInEUR} EUR`); // Salida: 100 USD equivalen a 85 EUR
Formateo de fechas
interface DateFormatOptions {
locale: string;
options: Intl.DateTimeFormatOptions;
}
function formatDate<T extends DateFormatOptions>(date: Date, format: T): string {
return date.toLocaleDateString(format.locale, format.options);
}
const currentDate = new Date();
const usDateFormat: DateFormatOptions = { locale: "en-US", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const germanDateFormat: DateFormatOptions = { locale: "de-DE", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const japaneseDateFormat: DateFormatOptions = { locale: "ja-JP", options: { year: 'numeric', month: 'long', day: 'numeric' } };
console.log("Fecha de EE. UU.: " + formatDate(currentDate, usDateFormat));
console.log("Fecha de Alemania: " + formatDate(currentDate, germanDateFormat));
console.log("Fecha de Japón: " + formatDate(currentDate, japaneseDateFormat));
Servicio de traducción
interface Translation {
[key: string]: string; // Permite claves de idioma dinámicas
}
interface LanguageData<T extends Translation> {
languageCode: string;
translations: T;
}
const englishTranslations: Translation = {
"hello": "Hello",
"goodbye": "Goodbye",
"welcome": "Welcome to our website!"
};
const spanishTranslations: Translation = {
"hello": "Hola",
"goodbye": "Adiós",
"welcome": "¡Bienvenido a nuestro sitio web!"
};
const frenchTranslations: Translation = {
"hello": "Bonjour",
"goodbye": "Au revoir",
"welcome": "Bienvenue sur notre site web !"
};
const languageData: LanguageData<typeof englishTranslations>[] = [
{languageCode: "en", translations: englishTranslations },
{languageCode: "es", translations: spanishTranslations },
{languageCode: "fr", translations: frenchTranslations}
];
function translate<T extends Translation>(key: string, languageCode: string, languageData: LanguageData<T>[]): string {
const lang = languageData.find(lang => lang.languageCode === languageCode);
if (!lang) {
return `Traducción para ${key} en ${languageCode} no encontrada.`;
}
return lang.translations[key] || `Traducción para ${key} no encontrada.`;
}
console.log(translate("hello", "en", languageData)); // Salida: Hello
console.log(translate("hello", "es", languageData)); // Salida: Hola
console.log(translate("welcome", "fr", languageData)); // Salida: Bienvenue sur notre site web !
console.log(translate("missingKey", "de", languageData)); // Salida: Traducción para missingKey en de no encontrada.
Conclusión
Los genéricos de TypeScript son una herramienta poderosa para escribir código reutilizable y seguro en cuanto a tipos que puede funcionar con tipos de datos complejos. Al comprender la sintaxis básica, las características avanzadas y las mejores prácticas de los genéricos, puedes mejorar significativamente la calidad y la mantenibilidad de tus aplicaciones TypeScript. Al desarrollar aplicaciones para una audiencia global, los genéricos pueden ayudarte a manejar diversos formatos de datos y convenciones culturales, asegurando una experiencia de usuario fluida para todos.